Skip to content

Raise TypeError instead of recursing on unsupported operator directions - #152

Merged
Martin-Molinero merged 3 commits into
QuantConnect:masterfrom
Martin-Molinero:bug-reflected-operator-recursion
Oct 16, 2026
Merged

Martin-Molinero merged 3 commits into
QuantConnect:masterfrom
Martin-Molinero:bug-reflected-operator-recursion

Conversation

@Martin-Molinero

@Martin-Molinero Martin-Molinero commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

What

An operation a CLR type supports in one direction only now raises TypeError, like any unsupported Python operation. Before, it recursed until RecursionError.

Type Expression Before After
declares only x + x 1.0 + x, 1 + x, sum([x]), x + 1.0 RecursionError (except x + 1.0) TypeError
declares only 1.0 + x x + 1.0, x + x RecursionError TypeError
[Flags] enum 1 & flags, 1 | flags RecursionError TypeError

The supported directions are unchanged. A Python subclass can still define the missing method, e.g. its own __radd__.

Why

InitClassBase fills the type dict with the CLR operators, e.g. __add__. Then FixupSlots installs the generic nb_add slot, and PyType_Ready adds a slot wrapper for each forward and reflected name the dict lacks, e.g. __radd__. With no CLR overload behind it, that wrapper calls back into the same slot that dispatched to it:

slot_nb_add → __radd__ wrapper → nb_add → slot_nb_add → …

So 1.0 + x loops until RecursionError. Code that catches TypeError to fall back (sum(), numeric helpers) gets a RecursionError instead. This happens on master 56b3b6e7 with a plain class that has no implicit conversion. It's not related to #150/#151, though I found it while reviewing #151.

How

After PyType_Ready, OperatorMethod.RemoveUnboundSlotWrappers goes through the slots FixupSlots installed for the type. It removes the forward and reflected entries that are CPython slot wrappers (wrapper_descriptor, the objects PyType_Ready adds). CLR operators, and any CLR member named like a dunder, are pythonnet objects and stay. With no __radd__ to find, the slot returns NotImplemented, so Python raises its usual TypeError.

Only operator slots this type installed are touched. Native slots implemented by pythonnet itself (nb_bool, in-place event slots) and comparison wrappers are left alone.

The cleanup is best effort and never fails the type creation: a failed delete clears its Python error, and any exception is swallowed. A wrapper left behind only keeps the previous behaviour.

Cost: about 1 µs per class, once when its Python type is created, which itself takes ~150–215 µs. There's no cost per operator call.

Where

  • src/runtime/Types/OperatorMethod.cs: RemoveUnboundSlotWrappers
  • src/runtime/TypeManager.cs: called after PyType_Ready in InitializeClass
  • src/embed_tests/TestOperator.cs: tests

Tests

  • New in TestOperator:

    • UnsupportedReflectedOperationRaisesTypeError
    • UnsupportedForwardOperationRaisesTypeError
    • UnsupportedReflectedFlagsOperationRaisesTypeError
    • PythonSubclassCanStillDefineTheReflectedOperation
    • ClrMemberNamedLikeTheReflectedMethodIsKept: a C# method literally named __radd__ is kept and used

    The first four fail on master and pass with the fix. The last one pins that only CPython's wrappers are removed.

  • Full embed suite: 998 passed, 0 failed (net10.0, Python 3.11).

  • Same wrappers removed after narrowing the rule: across 1,154 types from .NET's own assemblies, the same 79 types end up with an operator in one direction only, e.g. every [Flags] enum, DateTime, TimeSpan, Decimal, Int128 and the Vector/Matrix types.

  • Lean Python regression algorithms: 339/339 passed with this build dropped into Lean master 154e50720c, following .github/workflows/lean-python-regression-tests.yml.

🤖 Generated with Claude Code

PyType_Ready adds a slot wrapper for the forward and the reflected method
of each number slot FixupSlots sets, when the type dict lacks them. A type
declaring an operator in one direction only, like x + x, got a __radd__
wrapper that calls the very slot that dispatched to it, so 1.0 + x,
1 + x or sum([x]) recursed until RecursionError instead of raising
TypeError. The same happened with [Flags] enums, whose bitwise operators
are forward only (1 & flags).

The wrappers without a CLR operator behind them are now removed after
PyType_Ready, so the slot returns NotImplemented and Python raises its
usual TypeError. A Python subclass can still define the missing method.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Martin-Molinero and others added 2 commits October 16, 2026 12:48
The cleanup now deletes a member only when it is a wrapper_descriptor, the
object PyType_Ready adds, instead of anything that is not a managed
object. It is best effort: a failed delete clears its error and any
exception is swallowed, since a wrapper left behind only keeps the
previous behavior.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Martin-Molinero
Martin-Molinero merged commit 31ccaf3 into QuantConnect:master Oct 16, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant